其他
58 同城 App 性能治理实践-iOS 启动时间优化
导读
启动速度是用户体验一款 APP 的第一印象,良好的启动速度对于提升用户体验有着积极的作用。58 同城 APP 作为一款承载招聘、安居客、黄页、二手车等各大业务线的平台型 APP,复杂的业务启动逻辑与众多 SDK 初始化逻辑对 58 同城的启动治理带来了不少挑战。
挑战与治理思路
如何准确、稳定地衡量 58 同城 APP 的启动时间,以及如何横向比较 58 同城 APP 在业界主流 APP 中的启动时间? APP 启动变慢了,如何快速找出并定位是哪些耗时方法导致的启动速度降低? 在某个版本进行了启动优化,下个版本的启动耗时又突然爆发式增长,如何监控各个版本的启动耗时数据,及早的介入新增版本的启动耗时优化?
启动时间衡量
1、启动时间的定义
pre-main 阶段,从点击图标到 main 函数执行前:
动态库加载,包含系统动态库及自定义动态库; Rebase,修正当前镜像内部的指针偏移; Bind,修正不同镜像之间的外部指针偏移; Objc 初始化,包括 Objc 类、Category 的注册,以及 selector 的唯一性检查; Initializer 初始化,每个类和 Category 的 load 方法执行、C/C++ 构造函数调用、非基本类型的 C++ 静态全局变量初始化;
post-main 阶段,main 函数执行到首屏展现:主要执行各种 SDK 注册、各种业务初始化以及准备首屏渲染需要的数据等逻辑;
2、如何测量启动时间?
直接通过 Xcode 自带的 Timer Profier 工具进行测量,在xcode11 之后 Instrument 提供了 App Launch 工具,可以看到 pre-main 阶段的各个过程的耗时; 分别统计 pre-main 阶段和 post-main 阶段,其中 pre-main 阶段通过设置 Xcode 运行环境来获取(Project→Scheme→Edit Scheme…,在 Environment Variables 中添加 DYLD_PRINT_STATISTICS=1 的环境变量),post-main 阶段可以通过手动埋点的方式来获取;
启动时间统计工具
{
"log_timestamp" : "",
"init_count" : 1,
"machine_config" : "iPhone12,3",
"metrics" : [
{
"app_sessionreporter_key" : "",
"app_build_version" : "",
"app_version" : "10.10.0",
"app_adamid" : 0,
"app_arch" : "",
"app_bundleid" : "",
"performance_metrics" : {
"memory" : {
"average" : 1000,
"peak" : 1000
},
"app_performance" : {
"launch" : {
"count" : 2,
"sessions" : [
1250,
1500
]
}
}
},
"app_is_clip" : 0
}
]
}
重启手机;
点击其他应用,尽量将该应用在 APP 内的缓存给替换掉;
运行多次,去掉偏差较大的值,取平均值;
获取线上用户启动耗时
单机版
的启动耗时测量方案,对于线上实际用户的启动耗时数据获取,可以通过下面几种方式进行:通过 Xcode 自带工具来查看,选择 Xcode—>Window—>Organizer
,在左侧菜单栏选择 Launch Time 项查看线上用户 APP 的启动耗时数据,这种方式主要看线上用户整体启动耗时区间分布情况;通过获取进程信息,拿到进程创建时间作为启动初始点,如下代码。
这种方式经过实际测试发现,获取到的进程创建时间偏差较大。/**获取进程创建时间*/
+ (NSTimeInterval)processStartTime {
struct kinfo_proc kinfo;
if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kinfo]) {
return kinfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kinfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
} else {
return 0;
}
}
+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo {
int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(*procInfo);
return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}
创建一个自定义动态库(或直接使用已有的自定义动态库),在 +load 方法中进行埋点作为 APP 的启动时间,为了尽可能将其他动态库中的耗时统计到,我们可以将自定义的动态库放在所有动态库加载的第一位。
启动治理实践
pre-main
阶段和post-main
阶段的启动方法。1、启动耗时方法检测工具
1.1、+load 方法耗时检测
1.1.1、+load 耗时统计的技术方案
/** 读取含load方法的分类__objc_nlcatlist section,读__objc_nlclslist section类似 */
- (void)readLoadCategoryListSection:(const uint64_t)mach_header{
const struct section_64 *non_lazy_nlcatlist_section = getsectbynamefromheader_64((void *)mach_header, "__DATA", "__objc_nlcatlist");
if (non_lazy_nlcatlist_section == NULL) {
return;
}
for (uint64_t offset = non_lazy_nlcatlist_section->offset; offset < non_lazy_nlcatlist_section->offset + non_lazy_nlcatlist_section->size; offset += sizeof(const void **)) {
struct category_t *cat_ref = *(struct category_t **)(mach_header + offset);
Class cls = cat_ref->cls;
if (cls == NULL) {
continue;
}
[self.loadClassArray addObject:cls];
NSMutableArray *categoryArray = self.classKeyCategoryValueMap[cls];
if (!categoryArray) {
categoryArray = [NSMutableArray array];
self.classKeyCategoryValueMap[(id<NSCopying>)cls] = categoryArray;
}
[categoryArray addObject:@((uintptr_t)cat_ref)];
}
}
Mach-O 文件解析可以参考 58 开源项目:WBBlades
IMP originLoadIMP = origin_load_method->imp;
IMP hookLoadIMP = imp_implementationWithBlock(^(__unsafe_unretained id self, SEL cmd){
uint64_t starttime = currentTime();
((void (*)(id, SEL))originLoadIMP)(self, cmd);
uint64_t endtime = currentTime();
recordLoadTrace(array, invokeMethodName, endtime - starttime);
});
origin_load_method->imp = hookLoadIMP;
1.1.2、遇到的一个坑
1.1.3、load 耗时优化
如果时机可以的话,优先使用 +initialize 方法替换 load 方法; 继续使用 load 方法,但是通过监听启动完成后的一个通知,再执行原来的一些耗时逻辑,从而将耗时逻辑尽可能的延后; 另一种方案就是利用 Clang 提供的编译器函数实现对 Mach-O 的写能力,通过使用__attribute__((used, section("__DATA,__wbce_func")))来标记函数,在编译期时,编译器会将标记的数据写入到指定的 __DATA 段的__wbce_func section中,在运行时,通过读取 Mach-O 的 __wbce_func 节,取到保存的函数地址并执行。
实际上,获取到所有 load 数据之后,我们发现,正常一个普通 load 方法是不耗时的,一个耗时的 load 方法主要是在里面进行了 Method Swizzling、数据存储等耗时操作.
1.2、OC 方法耗时检测
1.2.1、OC 方法耗时检测方案
可以看到,OC 方法的执行必然会经过 objc_msgSend,因此如果我们能 hook 掉 objc_msgSned 方法,也就能拦截到所有 OC 方法的执行过程,这样我们在原方法的前后插入时间统计代码就能够计算出原方法的执行时间了。
typedef struct {
__unsafe_unretained Class cls;
SEL sel;
uint64_t time;
int depth;
} LTCallRecord;//一条方法调用记录
typedef struct {
LTCallRecord *record;
int allocated_length;
int index;
} LTMainThreadCallRecord;//方法调用队列
1.2.2、基于 OC 方法耗时检测的优化效果
1.3、基于方法耗时检测工具在版本监控上的应用
2、二进制重排实践
基于静态扫描+运行时 trace 的方案来获取启动时的符号,从而生成 order file 文件实现二进制重排; 基于 Clang 静态插桩的方式来获取启动过程中的所有函数符号;
2.1、虚拟内存与 Page Fault
当进程要访问的一个虚拟内存页在经过映射表映射之后发现对应的物理内存页不存在时,会触发一次缺页中断Page Fault,此时会发生 I/O 操作,将磁盘中的数据读入到物理内存页中,读取的过程中苹果还会对读入的内存页进行验签处理,因此如果频繁发生Page Fault的话,Page Fault产生的耗时也不可小觑。Page Fault的数量可以通过 Instruments 自带的 System Trace 工具来查看,其中File Backed Page In就是Page Fault的次数。
2.2、二进制重排优化原理
因此,二进制重排的一个核心问题就是如何将不同的方法尽可能地排列在同一个内存页中。
一个 order 文件内存储的是符号列表,当我们配置了 order 文件之后,ld 在工作的时候就会根据 order 文件中的符号按照顺序进行排列生成二进制文件。
2.3、Clang 插桩收集启动过程中的函数符号
一种是自己编写一个 Clang 插件,在 Clang 插件中我们去分析抽象语法树不同的节点,在相应的节点中插入自定义的代码用于符号收集,这种自定义 Clang 插件的方式优点是可根据自己需求进行灵活处理,缺点是通用性较差, 一种是利用 SanitizerCoverage 工具进行符号收集。
Clang 静态插桩收集符号的原理就是,利用编译期在每一个函数内部插入回调函数 __sanitizer_cov_trace_pc_guard
,我们通过实现该函数,在运行期间就能够拿到被插入该函数的原函数地址,通过函数地址解析出函数符号,从而达到收集启动过程中函数符号的目的。
//插桩的初始化方法,首次会进入到这里面
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
static uint64_t N;
if (start == stop || *start) return;
for (uint32_t *x = start; x < stop; x++)
*x = ++N;
}
//每个原函数内部被插入的回调方法
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
}
void *PC = __builtin_return_address(0);
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
void *PC = __builtin_return_address(0);
Dl_info info;
dladdr(PC, &info);
printf("fname=%s \nfbase=%p \nsname=%s\nsaddr=%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
char PcDescr[1024];
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
多线程问题,由于__sanitizer_cov_trace_pc_guard函数是各个方法内插入的回调函数,而原函数可能处于不同的线程中,从而造成__sanitizer_cov_trace_pc_guard函数调用的多线程问题,解决这个问题可以使用原子队列 OSAtomicEnqueue 来处理,使用原子队列之后需要在 Other C Flags 配置中修改原来的配置为如下形式:
-fsanitize-coverage=func,trace-pc-guard
如果要支持 Swift 符号收集,由于 Swift 的编译前端与 OC 不同,需要在编译配置的Other Swift Flags下,新增下面配置:
-sanitize-coverage=func
-sanitize=undefined
使用 Cocoapods 管理的项目,存在多 target 的情况下,需要在每个 target 下都要进行上面的Other C Flags配置。
2.4、二进制重排前后的效果对比
3、动态库懒加载
一个是动态库转静态库; 一个是多个动态库进行合并;
3.1、动态库懒加载方案
Pods-
xxx.adhoc/debug/release.xcconfig文件则负责静态库和动态库的链接配置,我们自定义的动态库想要进行懒加载,只需要修改xxx.xcconfig
配置文件,将需要懒加载的动态库从配置文件中移除,这样保证懒加载的动态库参与签名和拷贝,但是不参与链接。3.2、动态库懒加载后的调用方式
dlopen([path UTF8String], RTLD_LAZY);
3.3、有益效果
总结与展望
[1] Clang 12 documentation:https://clang.llvm.org/docs/SanitizerCoverage.html
[2] WBBlades:基于Mach-O文件解析的APP分析工具:https://mp.weixin.qq.com/s/HWJArO5y9G20jb2pqaAQWQ
[3] 基于二进制文件重排的解决方案 APP启动速度提升超15%:https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q
[4]App 启动速度怎么做优化与监控?:https://time.geekbang.org/column/article/85331
[5] 监控所有的OC方法耗时:https://juejin.cn/post/6844903875804135431
朴惠姝,58 同城 – 平台技术部 – iOS 技术部 高级研发工程师
邓竹立,58 同城 – 平台技术部 - iOS 技术部 资深研发工程师
Taro 3.2 适配 React Native 之样式内幕
Taro3.2 适配 React Native 之运行时架构详解